﻿using System;
using System.Collections;
using System.Collections.Generic;
using System.Text;
using System.Text.RegularExpressions;
using MindTouch.Dream;
using Novell.Directory.Ldap;

namespace BrianE.Deki.Services
{
    [
    DreamServiceBlueprint("dekiwiki/service-type", "authentication")
    , DreamService("Active Directory Authentication Service", "BrianE 2008", Info = "http://www.codeplex.com/DekiWikiADAuth/", SID = new string[] { "sid://BrianE/2008/08/active-directory-authentication" })
    , DreamServiceConfig("searchbase", "string", "The distinguished name (DN) of the domain. For example: 'DC=sd,DC=mindtouch,DC=com'")
    , DreamServiceConfig("usernameattribute", "string?", "LDAP attribute for retrieving a users account name.")
    , DreamServiceConfig("displayname-pattern", "string?", "Returns a friendlier name that can be customized by ldap attributes. Example: {sn}, {givenname}")
    , DreamServiceConfig("hostname", "string", "hostname or ip of domain controller or ldap server. Port 389 used by default. Port 636 used by default with SSL enabled.")
    //, DreamServiceConfig("ssl", "bool?", "Use LDAPS mode. This requires your LDAP server to be running with SSL and for the certificate to be recognized on the machine running this LDAP service. (default: false)")
    //, DreamServiceConfig("ssl-ignore-cert-errors", "bool?", "Allows you to use self signed or expired certificates. This should only be used for testing. (default: false)")
    , DreamServiceConfig("port", "int?", "Port used by host to service LDAP requests.  Defaults to 389 unless LDAPS is enabled.  Defaults to 636 for LDAPS.")
    , DreamServiceConfig("verboselogging", "bool?", "Will output more details to the log as TRACE level. Warning: usernames+passwords are included as well. Default: false")
    , DreamServiceConfig("groupnameattribute", "string?", "LDAP attribute for retrieving a group name.")
    , DreamServiceConfig("timeout", "int?", "Timeout for directory operations in milliseconds")
    , DreamServiceConfig("bindingdn", "string", "The DN to use for binding to LDAP. Use the user principal name.  example: dekibind@mydomain.local")
    , DreamServiceConfig("bindingpw", "string?", "Optional password for binding. Combined with a valid bindingdn account, queries to this service can be done without requiring credentials.")
    ]
    public class ActiveDirectoryAuthentication : MindTouch.Deki.DekiAuthenticationService
    {
        private string searchBase = "";
        private string usernameattribute = "samAccountName";
        private string displaynamepattern = "{samaccountname}";
        private string hostname = "";

        // TODO check Deki source to see how they implement logging
        private bool verboselogging;
        private string groupnameattribute = "";
        private int timeout = 5000;

        // TODO implement ssl - need test environment to check this
        private bool ssl;

        private int port = 389;
        private string bindingUserName = "";
        private string bindingPassword = "";
        private string upnSuffix = "";

        public override IEnumerator<IYield> Start(XDoc config, Result result)
        {

            searchBase = config["searchbase"].AsText ?? searchBase;
            displaynamepattern = config["displayname-pattern"].AsText ?? displaynamepattern;
            usernameattribute = config["usernameattribute"].AsText ?? usernameattribute;
            hostname = config["hostname"].AsText ?? hostname;
            ssl = config["ssl"].AsBool ?? false;
            
            if(config["port"].AsInt.HasValue) port = config["port"].AsInt.Value;
            else port = ssl ? 636 : 389;
            
            verboselogging = config["verboselogging"].AsBool ?? verboselogging;
            groupnameattribute = config["groupnameattribute"].AsText ?? groupnameattribute;
            timeout = config["timeout"].AsInt ?? timeout;
            bindingUserName = config["bindingdn"].AsText;
            upnSuffix = "@" + bindingUserName.Split("@".ToCharArray())[1];
            bindingPassword = config["bindingpw"].AsText;
            return base.Start(config, result);
        }

        public override bool CheckUserPassword(string user, string password)
        {
            LdapConnection ldap = new LdapConnection();

            ldap.Connect(hostname, port);

            try
            {
                ldap.Bind(user + this.upnSuffix, password);
                ldap.Disconnect();
                return true;
            }
            catch(Exception e)
            {
                return false;
            }
        }

        // TODO - Refactor GetUser logic into smaller easier to follow methods.
        public override User GetUser(string user)
        {
            LdapConnection ldap= new LdapConnection();

            ldap.Connect(this.hostname,port);

            ldap.Bind(bindingUserName,bindingPassword);

            LdapEntry entry = FindOneEntry(ldap,
                                            this.searchBase,
                                            LdapConnection.SCOPE_SUB,
                                            "(" + usernameattribute + "=" + user + ")", null, timeout);

            if(entry == null)
            {
                return null;
            }

            string userName = GetAttribute(entry, usernameattribute);
            string displayName = GetDisplayName(entry);
            string userEmail = GetAttribute(entry, "mail");
            string firstName = GetAttribute(entry, "givenName");
            string lastName = GetAttribute(entry, "sn");
            string dn = GetAttribute(entry, "distinguishedName");
            string created = GetAttribute(entry, "whenCreated");
            string phone = GetAttribute(entry, "telephoneNumber");
            string description = GetAttribute(entry, "description");

            List<Group> dekiGroups = new List<Group> { this.DefaultGroup };

            // searching the user for their tokenGroups attribute so that AD will calculate it.
            entry = FindOneEntry(ldap, entry.DN, LdapConnection.SCOPE_BASE, "objectclass=*", new[] { "tokenGroups" }, timeout);
            LdapAttribute tokenGroups = entry.getAttribute("tokenGroups");

            if (tokenGroups == null)
            {
                return new User(userName, userEmail, dekiGroups.ToArray());
            }

            StringBuilder groupSearchFilter = new StringBuilder("(|");

            IEnumerator groupEnumerator = tokenGroups.ByteValues;
            while (groupEnumerator.MoveNext())
            {
                sbyte[] groupSid = (sbyte[]) groupEnumerator.Current;
                groupSearchFilter.Append("(objectSid=");
                groupSearchFilter.Append(ByteArrayToLdapEscapedSearchString(groupSid));
                groupSearchFilter.Append(")");
            }
            groupSearchFilter.Append(")");
            List<LdapEntry> ldapGroups = FindAllEntries(ldap,
                                                        this.searchBase,
                                                        LdapConnection.SCOPE_SUB,
                                                        groupSearchFilter.ToString(),
                                                        new[] {groupnameattribute}, timeout);

            foreach (LdapEntry group in ldapGroups)
            {
                dekiGroups.Add(new Group(group.getAttribute(groupnameattribute).StringValue));
            }
            ldap.Disconnect();
            User userFound = new User(userName, userEmail, dekiGroups.ToArray());            
            userFound.Custom.Add("ldap-dn",dn);
            userFound.Custom.Add("date.created", created);
            userFound.Custom.Add("phonenumber", phone);
            userFound.Custom.Add("displayname", displayName);
            userFound.Custom.Add("firstname", firstName);
            userFound.Custom.Add("lastname", lastName);
            userFound.Custom.Add("description", description);

            return userFound;
        }

        /// <summary>
        /// Returns a group from active directory with the given group name.  If none is found it returns null.
        /// </summary>
        /// <param name="group"></param>
        /// <returns></returns>
        public override Group GetGroup(string group)
        {
            LdapConnection ldap = new LdapConnection();

            ldap.Connect(this.hostname, port);

            ldap.Bind(bindingUserName, bindingPassword);

            LdapEntry entry = FindOneEntry(ldap,
                                            this.searchBase,
                                            LdapConnection.SCOPE_SUB,
                                            "(" + groupnameattribute + "=" + group + ")", null, timeout);

            string groupName = GetAttribute(entry, groupnameattribute);
            
            ldap.Disconnect();
            
            return groupName.Equals("") ? null : new Group(groupName);
        }

        /// <summary>
        /// Returns the display name for a given entry based on the attributes in the display name pattern.  If the attribute returns null an empty string is used.
        /// </summary>
        /// <param name="entry">LDAP entry to generate the display name for</param>
        /// <returns>Display name according to pattern</returns>
        private string GetDisplayName(LdapEntry entry)
        {
            string displayName = displaynamepattern;

            string[] patternAttributes = GetReplacementTokens(displaynamepattern);

            for(int i = 0; i < patternAttributes.Length; i++)
            {
                LdapAttribute attrib =
                    entry.getAttribute(patternAttributes[i].Substring(1, patternAttributes[i].Length - 2));
                displayName = displayName.Replace(patternAttributes[i], attrib != null ? attrib.StringValue : "");
            }

            return displayName;
        }
        /// <summary>
        /// Finds the tokens that need to be replace in the display name patter
        /// </summary>
        /// <param name="hasTokens">the string that has the tokens to be parsed</param>
        /// <returns>an array of the tokens that need to be replaced</returns>
        private static string[] GetReplacementTokens(string hasTokens)
        {
            Regex regEx = new Regex(@"(?<!\w)\{\w+}");
            MatchCollection matches = regEx.Matches(hasTokens);
            string[] attributes = new string[matches.Count];
            for(int i = 0; i < matches.Count; i++)
            {
                attributes[i] = matches[i].Value;
            }
            return attributes;
        }
        /// <summary>
        /// Execute LDAP search and return a single entry that matches the criteria.
        /// </summary>
        /// <param name="ldap">LDAP Connection</param>
        /// <param name="searchRoot">The base to start the search</param>
        /// <param name="searchScope">The scope of the search Base, SubTree, OneLevel</param>
        /// <param name="filter">search filter</param>
        /// <param name="attributes">The attributes to return</param>
        /// <returns>The first LDAP entry found that matched the criteria or null if no matches were found.</returns>
        private static LdapEntry FindOneEntry(LdapConnection ldap, string searchRoot, int searchScope, string filter, string[] attributes, int timeout)
        {
            List<LdapEntry> entries = FindAllEntries(ldap, searchRoot, searchScope, filter, attributes, timeout);
            return entries.Count > 0 ? entries[0] : null;
        }
        /// <summary>
        /// Execute LDAP search and return a all entries that match the criteria.
        /// </summary>
        /// <param name="ldap">LDAP Connection</param>
        /// <param name="searchRoot">The base to start the search</param>
        /// <param name="searchScope">The scope of the search Base, SubTree, OneLevel</param>
        /// <param name="filter">search filter</param>
        /// <param name="attributes">The attributes to return</param>
        /// <returns>A list of LDAP entries found that match the criteria or a list with a single null item if no matches are found.</returns>
        private static List<LdapEntry> FindAllEntries(LdapConnection ldap, string searchRoot, int searchScope, string filter, string[] attributes, int timeout)
        {
            LdapSearchConstraints constraints = new LdapSearchConstraints(new LdapConstraints(timeout, true, null, 0));
            LdapSearchResults searchResults = ldap.Search(searchRoot, searchScope, filter, attributes, false, constraints);

            List<LdapEntry> entries = new List<LdapEntry>(1);
            
            while (searchResults.hasMore())
            {
                try
                {
                    entries.Add(searchResults.next());
                }
                catch (LdapException e)
                {
                    continue;
                }
            }
            return entries;
        }
        /// <summary>
        /// Converts a byte array into a string that can be used for LDAP queries
        /// </summary>
        /// <param name="array"></param>
        /// <returns></returns>
        private static string ByteArrayToLdapEscapedSearchString(sbyte[] array)
        {
            StringBuilder hexString = new StringBuilder();

            for(int i = 0; i < array.Length; i++)
            {
                hexString.Append("\\");
                hexString.Append(array[i].ToString("X2"));
            }

            return hexString.ToString();
        }
        /// <summary>
        /// Gets the string representation of the given attribute for the given entry.
        /// </summary>
        /// <param name="entry"></param>
        /// <param name="attributeName"></param>
        /// <returns>The attribute as a string.  An empty string if the attribute is not found.</returns>
        private static string GetAttribute(LdapEntry entry, string attributeName)
        {
            LdapAttribute attrib = entry.getAttribute(attributeName);
            return attrib != null ? attrib.StringValue : "";
        }
    }

}
